前端做的檢查是給使用者看的,不是給攻擊者看的。
阿哲用 AI 做了一個履歷健檢網站:貼上履歷,AI 給修改建議。免費會員每天能用三次,付費會員無限次。
次數限制寫在畫面上:今天用滿三次,按鈕就變成灰色,旁邊跳出「升級付費方案」。
一週後,AI API 的帳單讓他嚇了一跳。他去翻紀錄,發現有個免費帳號一天用了四百次。
那個人沒有駭進任何系統。他打開瀏覽器內建的開發者工具,找到按下按鈕時送出的請求,複製下來,自己重送了四百次。伺服器收到請求就照做,因為「一天三次」這條規則,只存在於阿哲的畫面上。
畫面上的限制,攔住的是守規矩的人。
第 1 天說過,服務由前端、後端、資料庫、第三方服務四塊組成,而前端在使用者手上。今天把這句話講得更具體一點。
你打開一個網站時,瀏覽器會把畫面的程式碼整份下載到你的電腦上執行。這代表每一個使用者都可以:
這些事不需要任何駭客工具。每個瀏覽器都內建「開發者工具」,按 F12(Mac 是 Cmd+Option+I)就能打開。
用昨天的五個思考模型來說,這篇只問第一個問題「這份資料從哪裡來?誰能控制它?」。答案是:從瀏覽器來的一切,都由使用者控制。trust boundary 畫在瀏覽器和伺服器之間,檢查只能做在邊界的伺服器那一側。
誤會一:把按鈕藏起來,別人就不能用。
藏起來的是按鈕,不是功能。按鈕背後的請求還在,任何人都能直接送。第 13 天會看到,管理員後台常常也是這樣「保護」的。
誤會二:在畫面上檢查過,資料就是乾淨的。
輸入格式、剩餘次數、付款金額,畫面上的檢查都可以被跳過。伺服器收到的每一筆資料,都要當成「沒檢查過」。前端檢查仍然有價值,它讓正常使用者早點看到錯誤訊息,但它是使用者體驗,不是安全措施。
誤會三:金鑰放在環境變數裡,就是藏起來了。
要看是哪一種環境變數。很多框架規定,名稱開頭是 NEXT_PUBLIC_ 或 VITE_ 的變數,會被直接寫進送給瀏覽器的程式碼。AI 為了讓功能快點動起來,有時會直接從前端呼叫 AI 服務,把 API key 放進這種變數裡。
這不是少數案例。資安公司 Escape 在 2025 年 10 月掃描了 5,600 多個用 Lovable、Base44、Bolt.new 等工具做出來、已經公開上線的應用程式,找到 400 多個外洩的機密(API key、access token 等),研究報告特別提到其中一個主要來源是前端的 JavaScript bundle。
用 Chrome 打開你的網站,先登入,把主要的頁面都點過一輪(有些程式碼要打開那一頁才會下載),然後按 F12:
原始碼(Sources)分頁:按 Ctrl+Shift+F(Mac 是 Cmd+Option+F)搜尋整個網站的程式碼。依序搜尋:
sk-、sk_live、sb_secret:常見的 AI 服務、金流、Supabase secret key 開頭eyJ:JWT 都是這樣開頭,Supabase 的舊版金鑰也是(下面「公開金鑰」一節會教你怎麼判斷它是哪一種)secret、apiKey、password
搜得到的,全世界都搜得到。
網路(Network)分頁:操作一次付費功能或有次數限制的功能,看送出了哪個請求、送到哪個網址。如果請求直接送到 api.openai.com 這類第三方服務,代表金鑰一定在瀏覽器裡。
應用程式(Application)分頁:看左側「本機儲存空間(Local Storage)」和「Cookie」裡存了什麼。有沒有 token?有沒有 isPremium: true、remaining: 3 這種看起來可以自己改的值?
(括號內是英文介面的名稱。)
請檢查這個專案,列出兩份清單,先不要修改任何程式碼:
清單一:所有會被打包進瀏覽器端程式碼的環境變數、常數和金鑰。
每一項標出它是不是機密:能產生費用、能讀寫所有資料、能冒充伺服器的,都算機密。
如果是 Supabase 或 Firebase 的金鑰,說明它是設計上可以公開的那一種,還是不能公開的那一種。
清單二:所有「只在前端檢查」的權限或限制。
例如付費功能、使用次數、管理員功能、輸入格式、價格。
每一項說明伺服器端有沒有做同樣的檢查,附上前端和伺服器端的檔案位置;伺服器端沒有檢查的,明確寫「沒有」。
清單出來之後,請 AI 修正時,把這兩句規則一起給它:
機密只能在伺服器端使用,不能出現在任何會送到瀏覽器的程式碼裡。
所有權限和限制都要在伺服器端檢查;前端的檢查只負責顯示,可以保留,但不能是唯一的一道。
已經被打包進前端的金鑰,改完程式碼還不算修好。它早就公開過了,要到服務後台撤銷、換一把新的(第 28 天會完整談)。
三個測試都在瀏覽器裡做,不用看程式碼。
**第一步:複製請求。**用付費帳號操作一次功能,在網路分頁找到那個請求,按右鍵 →「複製」→「複製為 fetch 格式」(英文介面是 Copy → Copy as fetch)。
**第二步:換個身分重送。**把剛剛複製的內容,貼到下面三種情境的「主控台(Console)」分頁執行。第一次貼上時 Chrome 會擋下來,照畫面指示輸入「允許貼上」(英文介面是 allow pasting)再貼一次。
| 測試 | 怎麼做 | 應該看到 |
|---|---|---|
| 未登入 | 開無痕視窗,打開你的網站但不登入,在主控台貼上請求。如果內容裡有 "authorization" 那一行,先把它刪掉 |
狀態碼 401 或 403 |
| 免費帳號 | 用免費帳號登入,貼上付費帳號複製來的請求。如果內容裡有 "authorization" 那一行,把它的值換成免費帳號的(免費帳號送出的任何一個請求裡都看得到) |
狀態碼 403 |
| 超過次數 | 用免費帳號登入,把請求包在迴圈裡連送 5 次(見下方) | 前 3 次成功,之後 429 或 403 |
超過次數的測試,把複製來的 fetch(...) 整段貼進下面的迴圈:
for (let i = 1; i <= 5; i++) {
const res = await fetch(/* 把複製來的 fetch 括號裡的內容貼在這裡 */);
console.log(i, res.status);
}
第三步:重新做一次 30 秒自我檢查的搜尋,機密應該搜不到了。記得是對重新部署之後的網站搜尋。
狀態碼在主控台看得到,也可以回到網路分頁看「狀態」欄。三個測試只要有一個回應 200 而且真的產生了結果,就代表伺服器沒有擋。
前端程式碼上線前,會經過框架或建置工具裡的 bundler 打包成幾個 JavaScript 檔,這些檔案叫 bundle。打包時,bundler 會把特定前綴的環境變數,用字串常數直接替換進程式碼:
| 工具 | 會送進瀏覽器的前綴 | production 預設產生 source map 嗎 |
|---|---|---|
| Next.js | NEXT_PUBLIC_ |
不會(productionBrowserSourceMaps 預設關閉) |
| Vite | VITE_ |
不會(build.sourcemap 預設 false) |
| Create React App | REACT_APP_ |
會(要設 GENERATE_SOURCEMAP=false 才關) |
以 Next.js 為例,你寫的是:
fetch(url, { headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_KEY}` } });
使用者下載到的是:
fetch(url, { headers: { Authorization: `Bearer sk-proj-xxxxxxxx` } });
幾個容易被忽略的細節:
.env 刪掉變數,已經部署的 bundle 不會變;很多部署平台還會保留舊版本的網址。所以外洩過的金鑰只能撤銷,不能靠刪除。NEXT_PUBLIC_ 也可能外洩。只要一段程式碼在瀏覽器執行,它用到的任何值最後都在使用者手上。例如在 Next.js 的 client component(檔案開頭有 "use client")裡寫死金鑰字串。把應該在伺服器端做的安全檢查放到前端,在 CWE 叫做 CWE-602:Client-Side Enforcement of Server-Side Security;把金鑰寫死在程式碼裡,是 CWE-798:Use of Hard-coded Credentials。
用 AI 開發時最常見的後端服務 Supabase 和 Firebase,設計上就是讓前端直接連線,所以前端一定會有一把金鑰。重點不是「有沒有金鑰」,而是「是哪一種金鑰」。
| 平台 | 可以放前端 | 絕對不能放前端 | 真正保護資料的是 |
|---|---|---|---|
| Supabase(新版) | publishable key(sb_publishable_...) |
secret key(sb_secret_...) |
Row Level Security |
| Supabase(舊版) | anon key |
service_role key |
Row Level Security |
| Firebase | Firebase 設定裡的 apiKey(AIza...) |
同一個 Google Cloud 專案裡,用來呼叫 Gemini API 等付費服務的金鑰 | Firebase Security Rules、App Check |
Supabase 的 secret key 和 service_role key 會繞過所有 Row Level Security policy,拿到它就等於拿到整個資料庫。Supabase 對新版 secret key 加了一道保險:偵測到請求來自瀏覽器(依 User-Agent 判斷)時直接回 401。舊版 service_role key 沒有這道保險,而 Supabase 預計在 2026 年底前淘汰 anon 和 service_role 這兩種舊金鑰。
舊版金鑰都是 eyJ 開頭的 JWT,外表長得一模一樣。要分辨是哪一種,在自己網站的主控台執行下面這段(不要貼到線上的 JWT 解碼網站,那等於把金鑰交給別人):
const token = "貼上 eyJ 開頭的整串";
JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
結果裡的 role 是 anon 沒問題;是 service_role 就要立刻撤銷。
Firebase 的 apiKey 則有一段值得知道的插曲。Firebase 官方文件一直說,限制在 Firebase 服務的 API key 只是用來識別專案,不需要當成機密。但 Truffle Security 在 2026 年 2 月揭露:在同一個 Google Cloud 專案啟用 Gemini API 後,專案裡原本就公開在網頁上的 API key 會悄悄多出呼叫 Gemini 的能力。他們在 2025 年 11 月的 Common Crawl 公開網頁資料裡,找到 2,863 把受影響、還能用的金鑰。Google 之後改成用新的 auth key 呼叫 Gemini,並從 2026 年 6 月起拒絕沒有設限制的舊式 API key。
這件事的教訓是:「這把金鑰可以公開」是有前提的,前提是它的權限範圍沒有變。所以要定期檢查:前端那把金鑰,現在能呼叫哪些服務?
另外,「金鑰可以公開」的意思是資料的保護責任全部落在 Row Level Security 或 Security Rules 上。第 1 天的 Lovable 事件(CVE-2025-48757),就是 anon key 本來可以公開,但 Row Level Security 沒開。這是第 5 天的主題。
AI 很常在遇到跨網域錯誤時「修好」CORS,於是很多人以為 CORS 設定就是權限控制:「我只允許自己的網域,別人就不能呼叫我的 API。」
CORS(Cross-Origin Resource Sharing)限制的是:瀏覽器裡、另一個網站的 JavaScript,能不能讀取你 API 的回應。它保護的是使用者的瀏覽器,不是你的伺服器。它管不到:
curl、Postman、任何腳本直接呼叫你的 API,這些工具根本不看 CORS最後一點我自己踩過。以前做前後端分離的專案,前端在 www 網域、API 在 api 網域,我發現同樣是 POST,用 application/json 會先送一個 OPTIONS 的 preflight request,用 application/x-www-form-urlencoded 卻直接送出去了。
原因是符合條件的請求(GET、HEAD、POST,Content-Type 只用 text/plain、multipart/form-data、application/x-www-form-urlencoded,而且沒有自訂標頭)屬於 simple request,瀏覽器不做 preflight,直接把請求送到伺服器。伺服器照常執行,瀏覽器只是在回應回來之後,不讓頁面讀取結果。如果那個請求是「刪除資料」或「扣款」,事情已經發生了。
所以 CORS 設定要正確,但它永遠不能取代伺服器端的身分與權限檢查。
登入之後,瀏覽器要記住「你是誰」,通常是存一個 token。AI 生成的前後端分離專案,最常見的寫法是把 token 存在 localStorage,每次呼叫 API 放進 Authorization 標頭,因為最好寫。
| 存放位置 | JavaScript 讀得到嗎 | 主要風險 |
|---|---|---|
| localStorage/sessionStorage | 讀得到 | 任何一個 XSS 漏洞就能把 token 讀出來、送到別的地方,攻擊者在自己的電腦上繼續使用 |
| HttpOnly cookie | 讀不到 | 瀏覽器會自動附上 cookie,要處理 CSRF(SameSite、CSRF token) |
OWASP 的 HTML5 Security Cheat Sheet 寫得很直接:不要把 session identifier 存在 local storage,因為 JavaScript 永遠讀得到。
但也別把 HttpOnly cookie 當成萬靈丹。XSS 發生時,攻擊者偷不走 token,還是可以趁使用者開著網頁,用使用者的身分送請求。差別在於傷害被限制在那個頁面開著的時間內,而且 token 不會流出去被重複使用。
所以真正的結論是:
登入與 session 的細節,第 14 天會再深入。
以 Next.js 為例。錯誤寫法:金鑰進了 bundle,次數限制只在畫面上。
// app/resume/page.tsx
"use client";
export default function ResumePage() {
const [remaining, setRemaining] = useState(3); // quota lives in the browser
async function review(text: string) {
if (remaining <= 0) return; // client-side only check
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_KEY}`, // inlined into the bundle
},
body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: text }] }),
});
setRemaining(remaining - 1);
// ...
}
// ...
}
修正方向:瀏覽器只呼叫自己的 API;身分、額度、金鑰都留在伺服器。
// app/api/resume-review/route.ts
import "server-only"; // build fails if this module is imported from client code
export async function POST(req: Request) {
const user = await getCurrentUser(req); // read the session on the server
if (!user) return new Response("Unauthorized", { status: 401 });
// Atomic check-and-increment in the database, so parallel requests
// cannot all pass the same "remaining > 0" check.
const allowed = await consumeDailyQuota(user.id, user.plan === "pro" ? Infinity : 3);
if (!allowed) return new Response("Daily limit reached", { status: 429 });
const { text } = await req.json();
if (typeof text !== "string" || text.length > 20_000) {
return new Response("Invalid input", { status: 400 });
}
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, // no NEXT_PUBLIC_ prefix
},
body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: text }] }),
});
return Response.json(await res.json());
}
前端只剩:
const res = await fetch("/api/resume-review", { method: "POST", body: JSON.stringify({ text }) });
if (res.status === 429) showUpgradeDialog(); // UI only; the server already enforced it
三個重點:
import "server-only" 讓這個檔案一旦被 client component 引用,build 就失敗。這是把「機密只能在伺服器端」從口頭規則變成工具會擋的規則。getCurrentUser、consumeDailyQuota 依你用的登入機制和資料庫實作,這裡只示範它們該在伺服器的哪個位置被呼叫。
原始碼掃描抓不到「build 時才被替換進去」的值,所以要在 build 之後,掃描真正要部署的產物。
下面這支腳本做兩件事:搜尋已知格式的機密字串,以及把 bundle 裡每個 JWT 解碼、找出 service_role。找到就回傳 exit code 1,讓 build 失敗,這次部署就停下來。
第一步:把腳本放進專案。在專案根目錄(和 package.json 同一層)建立 scripts 資料夾,新增 scan-client-bundle.sh,內容如下:
#!/usr/bin/env bash
# scripts/scan-client-bundle.sh
set -euo pipefail
BUNDLE_DIR="${1:-.next/static}" # Vite: dist, CRA: build
# Known secret key prefixes: OpenAI / Anthropic, Stripe live, Supabase secret
# -l prints file names only: minified bundles are one huge line, and printing it would leak the key into CI logs
if grep -rEl 'sk-(proj|ant|svcacct|admin)?-?[A-Za-z0-9_-]{20,}|sk_live_[A-Za-z0-9]{20,}|sb_secret_[A-Za-z0-9_-]{10,}' "$BUNDLE_DIR"; then
echo "Secret-like string found in client bundle" >&2
exit 1
fi
# Legacy Supabase keys are JWTs: decode each payload and fail on service_role
node -e '
const fs = require("fs"), path = require("path");
const walk = d => fs.readdirSync(d, { withFileTypes: true })
.flatMap(e => e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]);
let found = false;
for (const file of walk(process.argv[1]).filter(f => /\.(js|mjs|map)$/.test(f))) {
for (const jwt of fs.readFileSync(file, "utf8").match(/eyJ[\w-]+\.eyJ[\w-]+\.[\w-]+/g) ?? []) {
try {
const payload = JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString());
if (payload.role === "service_role") { console.error(`service_role JWT in ${file}`); found = true; }
} catch {}
}
}
process.exit(found ? 1 : 0);
' "$BUNDLE_DIR"
注意 service_role 這幾個字在 JWT 裡是 base64 編碼過的,直接 grep service_role 搜不到真正的金鑰,所以要先解碼。
第二步:在自己電腦上跑一次。在專案根目錄的終端機執行(Windows 請用 Git Bash 或 WSL):
npm run build
bash scripts/scan-client-bundle.sh .next/static
最後那個參數是「打包後、要送給瀏覽器的檔案」所在的資料夾,依工具不同:
| 工具 | 參數 |
|---|---|
| Next.js | .next/static(.next/server 是伺服器端程式,不會送到瀏覽器,不用掃) |
| Vite | dist |
| Create React App | build |
沒有找到問題時,畫面上什麼都不會出現。想確認的話,接著執行 echo $?,看到 0 就是通過。找到問題時會印出檔案路徑,例如:
.next/static/chunks/app/resume/page-1a2b3c.js
Secret-like string found in client bundle
或
service_role JWT in .next/static/chunks/4f5e6d.js
第三步:讓每次部署都自動跑。只在自己電腦跑一次,下次 AI 改程式碼時又可能漏掉。以下兩種放法選一種:
做法 A:接在 build 指令後面(用 Vercel、Netlify、Cloudflare Pages 這類平台自動部署的,就建議用這個)
打開 package.json,把 scripts 裡的 build 改成:
{
"scripts": {
"build": "next build && bash scripts/scan-client-bundle.sh .next/static"
}
}
(Vite 是 vite build && bash scripts/scan-client-bundle.sh dist,依此類推。)
部署平台每次都會執行 npm run build。&& 的意思是「前面成功才執行後面」,掃描失敗整個 build 就算失敗,平台不會部署這個版本,網站繼續跑上一個版本。這個做法的好處是:平台 build 時用的是 production 的環境變數,掃到的就是真正會上線的那份 bundle。
做法 B:放進 GitHub Actions(想在合併 PR 之前就擋下來)
在專案裡新增 .github/workflows/scan-client-bundle.yml:
name: Scan client bundle
on: [pull_request, push]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
env:
# Same public variable names as production, so the build inlines them the same way
NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ vars.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
- run: bash scripts/scan-client-bundle.sh .next/static
這裡有個陷阱:.env 通常不會 commit 進 git,GitHub Actions 裡預設沒有任何環境變數。build 出來的 bundle 裡金鑰都是空的,掃描永遠通過,等於沒掃。所以前端用到的每個 NEXT_PUBLIC_ 變數,都要到 GitHub repo 的 Settings → Secrets and variables → Actions 設定,再像上面的 env: 一樣傳進 build。如果覺得麻煩,用做法 A 就好。
也可以直接請 AI 幫你裝:
請在這個專案加入 scripts/scan-client-bundle.sh(內容如下),
並修改 package.json 的 build 指令,在打包完成後執行它。
依這個專案使用的框架,決定要掃描的 bundle 資料夾。
改完之後執行一次 npm run build,把掃描結果貼給我。
(貼上腳本內容)
掃到了怎麼辦?
這支腳本只抓得到已知格式的金鑰,格式不在清單上的就會漏掉。正式做法是 secret scanning 工具(第 20 天放進 CI,第 28 天談外洩後的處理);伺服器端的權限,則要用第 19 天的方法寫成直接打 API 的 regression test,例如:
test("free user cannot exceed daily quota via direct API calls", async () => {
const cookie = await loginAs("free-user@example.test");
const statuses = [];
for (let i = 0; i < 5; i++) {
const res = await fetch(`${BASE_URL}/api/resume-review`, {
method: "POST",
headers: { cookie, "Content-Type": "application/json" },
body: JSON.stringify({ text: "test" }),
});
statuses.push(res.status);
}
expect(statuses.slice(3)).toEqual([429, 429]);
});
server-only 這類機制,讓機密模組不可能被打包進前端isPremium、role、price(第 13、15 天)前端做的檢查是給使用者看的,不是給攻擊者看的。瀏覽器裡的每一行程式碼、每一個請求、每一把金鑰都是公開的;門鎖只能裝在伺服器上。